前端框架 Vue 学习笔记之 Reactivity

这一篇笔记的重点在于探究 Vue 的响应式特性,并动手实现一个简易的响应系统。

什么是响应式

在前端框架这个语境下是指,状态的改变引起相应的 DOM 随之改变。

我们先从一个最简单的情形说起,假如有 2 个变量 ab,我们要实现的需求是,让变量 b 的值总是变量 a 的 10 倍。

1
2
3
4
5
6
let a = 3;
let b = a * 10;
console.log(b); // 30
a = 4;
b = a * 10;
console.log(b); // 40

上面代码中,每次变量 a 发生变化,必须手动更新变量 b 的值,才能使得两者始终保持 10 倍的关系。如何声明式地表示这种关系呢?

在 Excel 中,我们是通过函数来实现类似效果的:

No.AB
1440(fx = A1 * 10)

在程序语言中,我们想要的是一个类似这样的函数:

1
2
3
onAChanged(() => {
b = a * 10;
});

每当变量 a 的值发生改变,作为回调函数参数的这个更新函数便执行(即上面代码中的箭头函数),这样问题就解决了。那么如何实现这样一个函数?

先将前面问题稍微转化一下,使之更符合 Web 开发的实际情况。

我们现在有一个 <span> 标签 b1,它的值是状态变量 a 的 10 倍:

1
<span class="cell b1" />
1
2
3
onStateChanged(() => {
document.querySelector(".cell.b1").textContent() = state.a * 10;
});

上面的代码实际上声明式地表达了状态与 DOM 之间的关系,不过我们可以更进一步抽象成这样:

1
<span class="cell b1"> {{ state.a * 10 }} </span>
1
2
3
onStateChanged((state) => {
view = render(state);
});

从上面的代码我们隐约看到了一个 UI 库的雏形,最关键的是这行代码:view = render(state),它实际上高度抽象地概括了现代前端框架存在的根本原因:通过一种映射,将应用程序的 UI 与状态同步。这里面涉及到很多虚拟 DOM 和原生 DOM 的细节,所以我们这里先关注外面的回调函数即 onStateChanged 是如何实现的。

它可能是这样实现的:

1
2
3
4
5
6
7
8
9
let update;
const onStateChanged = (_update) => {
update = _update;
};

const setState = (newState) => {
state = newState;
update();
};

上面代码中,我们将 update 函数保存在某处,同时要求用户总是通过调用一个函数 setState 来更新状态,而不是任意地操作状态。setState 函数所做的工作如下:将旧状态替换为新状态,然后调用 update 函数。这一过程与 React 的响应式系统很像,在应用状态改变时要求用户手动调用 setState 函数:

1
2
3
4
5
onStateChanged(() => {
view = render(state);
});

setState({ a: 5 });

而在 Vue 中,用户可以直接操作状态,状态改变会自动触发 onStateChanged 内更新函数的执行,不再需要用户手动调用 setState。这是通过 ES5 的全局 API Object.defineProperty() 来使得状态具有响应特性。

Getter / Setter

先热下身,利用 Object.defineProperty() 动手实现一个 convert() 函数,需求如下:

  • 接受一个对象类型的参数
  • 将该对象的属性就地转化为 getter/setter
  • 该对象应该保持原有的行为,同时在被访问和修改时打印 get/set 操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 实现
function convert(obj) {
Object.keys(obj).forEach((key) => {
let internalValue = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log(`get key "${key}": ${internalValue}`);
return internalValue;
},
set(newValue) {
console.log(`set key "${key}" to: ${newValue}`);
internalValue = newValue;
},
});
});
}

// 用例
const obj = {
foo: 0,
};

convert(obj);

obj.foo; // get key "foo": 0
obj.foo = 99; // set key "foo" to: 99
obj.foo; // get key "foo": 99

Dependency Tracking

很明显,以上所做的还不能实现预期的效果,我们还需要利用发布/订阅模式实现 Dependency Tracking 依赖追踪。

需求如下:

  • 创建一个 Dep 类,这个类有两个方法 depend()notify
  • 创建一个 autorun 函数,它接受一个 update 更新函数作为参数
  • update 更新函数内部,你可以通过调用 dep.depend() 显式地依赖一个 Dep 类的实例
  • 在这之后,你可以通过调用 dep.notify() 使得 update 更新函数再次触发被调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 实现
class Dep {
constructor() {
this.subscribers = new Set();
}

depend() {
if (activeUpdate) {
// register the current active update as a subscriber
this.subscribers.add(activeUpdate);
}
}

notify() {
// run all subscriber functions
this.subscribers.forEach((sub) => sub());
}
}

// 注意 autorun 函数的实现
// activeUpdate 这个全局变量标记了 update 函数当前是否被执行
let activeUpdate;
function autorun(update) {
function wrappedUpdate() {
activeUpdate = wrappedUpdate;
update();
activeUpdate = null;
}
wrappedUpdate();
}

// 用例
const dep = new Dep();

autorun(() => {
// 调用 dep.depend()
// 使得 update 函数被添加到 dep 的订阅列表 subscribers 中
dep.depend();
// 此处我们在 update 函数内只做了一件事即打印 "updated"
console.log("updated");
});
// 应该打印: "updated"

// 因为前面 autorun 的执行
// 使得 update 函数被 dep 订阅
// 在其他任何地方调用 dep.notify() 方法都将再次触发 update 函数的执行
dep.notify();
// 应该打印: "updated"

Mini Data Observer

将前面实现的 convert()autorun() 这 2 个函数结合起来,同时将 convert() 函数重命名为 observe()。实现的需求如下:

  • observe 函数接受一个对象类型的参数,并将该对象的所有属性转化成响应式的。每个被转化的属性都被分配到一个 Dep 实例,该实例跟踪着一个订阅了更新函数的列表,每当某个属性的 setter 被调用,就重新调用订阅的更新函数。
  • autorun 函数接受一个 update 函数作为参数,同时在 update 函数所订阅的属性被修改时,update 函数将被触发执行。如果 update 更新函数在执行期间依赖于某个属性,则该更新函数订阅了该属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<div>
<button onclick="down()">-</button>
<span class="count"></span>
<button onclick="up()">+</button>
</div>
<script>
// 实现
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeUpdate) {
this.subscribers.add(activeUpdate);
}
}

notify() {
this.subscribers.forEach((sub) => sub());
}
}

function observe(obj) {
Object.keys(obj).forEach((key) => {
let internalValue = obj[key];
let dep = new Dep();
Object.defineProperty(obj, key, {
get() {
dep.depend();
return internalValue;
},
set(newValue) {
const isChanged = internalValue !== newValue;
if (isChanged) {
internalValue = newValue;
dep.notify();
}
},
});
});
}

let activeUpdate;
function autorun(update) {
function wrappedUpdate() {
activeUpdate = wrappedUpdate;
update();
activeUpdate = null;
}
wrappedUpdate();
}

// 用例
const state = {
count: 0,
};

const $count = document.querySelector(".count");
$count.textContent = state.count;

observe(state);
autorun(() => {
console.log(`"state.count" updated: ${state.count}`);
// 每当 state.count 值改变,触发 DOM 更新
$count.textContent = state.count;
});

function up() {
state.count++;
}

function down() {
state.count--;
}
</script>

上面的代码实现了一个简单的 Data Observer,同时需要指出,有一些边缘情况没有考虑在内,比如清除陈旧的依赖、对数组的处理、对新添加属性的处理。